FluentValidation

Rapid overview

FluentValidation - Comprehensive Practice Exercises

Table of Contents

  1. Basic Validators
  2. Built-in Validation Rules
  3. Custom Validation Rules
  4. Async Validation
  5. Dependent Rules (When/Unless)
  6. Nested Validators
  7. Collection Validation
  8. RuleSet Usage
  9. Conditional Validation
  10. Integration with ASP.NET Core
  11. Error Message Customization
  12. Advanced Rule Composition
  13. Validator Testing & Diagnostics

---

Basic Validators

Exercise 1: Create Your First Validator

Question: Create a validator for a user registration request with basic validation rules.

Answer

// Application/DTOs/RegisterUserRequest.cs
public class RegisterUserRequest
{
    public string Username { get; set; }
    public string Email { get; set; }
    public string Password { get; set; }
    public string ConfirmPassword { get; set; }
    public int Age { get; set; }
}

// Application/Validators/RegisterUserRequestValidator.cs
public class RegisterUserRequestValidator : AbstractValidator<RegisterUserRequest>
{
    public RegisterUserRequestValidator()
    {
        RuleFor(x => x.Username)
            .NotEmpty().WithMessage("Username is required")
            .Length(3, 50).WithMessage("Username must be between 3 and 50 characters");

        RuleFor(x => x.Email)
            .NotEmpty().WithMessage("Email is required")
            .EmailAddress().WithMessage("Invalid email format");

        RuleFor(x => x.Password)
            .NotEmpty().WithMessage("Password is required")
            .MinimumLength(8).WithMessage("Password must be at least 8 characters");

        RuleFor(x => x.ConfirmPassword)
            .Equal(x => x.Password).WithMessage("Passwords must match");

        RuleFor(x => x.Age)
            .GreaterThanOrEqualTo(18).WithMessage("You must be at least 18 years old");
    }
}

// Usage
public class UserService
{
    private readonly IValidator<RegisterUserRequest> _validator;

    public UserService(IValidator<RegisterUserRequest> validator)
    {
        _validator = validator;
    }

    public async Task<Result> RegisterUserAsync(RegisterUserRequest request)
    {
        // Validate
        var validationResult = await _validator.ValidateAsync(request);

        if (!validationResult.IsValid)
        {
            var errors = validationResult.Errors
                .Select(e => e.ErrorMessage)
                .ToList();

            return Result.Failure(string.Join(", ", errors));
        }

        // Process registration
        // ...

        return Result.Success();
    }
}

// Program.cs - Register validators
builder.Services.AddValidatorsFromAssemblyContaining<RegisterUserRequestValidator>();

---

Exercise 2: Validate Manually

Question: Demonstrate different ways to manually trigger validation.

Answer

public class ValidationExamples
{
    private readonly IValidator<CreateProductRequest> _validator;

    public ValidationExamples(IValidator<CreateProductRequest> validator)
    {
        _validator = validator;
    }

    // 1. Basic validation
    public void BasicValidation(CreateProductRequest request)
    {
        var result = _validator.Validate(request);

        if (!result.IsValid)
        {
            foreach (var error in result.Errors)
            {
                Console.WriteLine($"Property: {error.PropertyName}");
                Console.WriteLine($"Error: {error.ErrorMessage}");
                Console.WriteLine($"Attempted Value: {error.AttemptedValue}");
            }
        }
    }

    // 2. Async validation
    public async Task AsyncValidation(CreateProductRequest request)
    {
        var result = await _validator.ValidateAsync(request);

        if (!result.IsValid)
        {
            // Handle errors
        }
    }

    // 3. Throw on failure
    public void ValidateAndThrow(CreateProductRequest request)
    {
        try
        {
            _validator.ValidateAndThrow(request);
            // Validation passed
        }
        catch (ValidationException ex)
        {
            // Validation failed
            var errors = ex.Errors;
        }
    }

    // 4. Validate specific properties
    public void ValidateSpecificProperty(CreateProductRequest request)
    {
        var result = _validator.Validate(request, options =>
        {
            options.IncludeProperties(x => x.Name);
            options.IncludeProperties(x => x.Price);
        });
    }

    // 5. Validate using RuleSet
    public void ValidateWithRuleSet(CreateProductRequest request)
    {
        var result = _validator.Validate(request, options =>
        {
            options.IncludeRuleSets("Create");
        });
    }

    // 6. Get specific validation result details
    public void GetValidationDetails(CreateProductRequest request)
    {
        var result = _validator.Validate(request);

        // Check if valid
        bool isValid = result.IsValid;

        // Get all errors
        var allErrors = result.Errors;

        // Get errors for specific property
        var nameErrors = result.Errors
            .Where(e => e.PropertyName == nameof(CreateProductRequest.Name))
            .ToList();

        // Get first error message
        var firstError = result.Errors.FirstOrDefault()?.ErrorMessage;

        // Get all error messages
        var errorMessages = result.Errors.Select(e => e.ErrorMessage).ToList();

        // Get errors as dictionary
        var errorDictionary = result.ToDictionary();
    }
}

public class CreateProductRequest
{
    public string Name { get; set; }
    public decimal Price { get; set; }
    public string Description { get; set; }
}

---

Built-in Validation Rules

Exercise 3: Explore Built-in Rules

Question: Demonstrate all major built-in validation rules.

Answer

public class BuiltInRulesValidator : AbstractValidator<SampleModel>
{
    public BuiltInRulesValidator()
    {
        // String validations
        RuleFor(x => x.RequiredField)
            .NotEmpty(); // Not null, empty, or whitespace

        RuleFor(x => x.NotNullField)
            .NotNull(); // Not null (allows empty string)

        RuleFor(x => x.LengthField)
            .Length(5, 100); // Between 5 and 100 characters

        RuleFor(x => x.MinLengthField)
            .MinimumLength(5); // At least 5 characters

        RuleFor(x => x.MaxLengthField)
            .MaximumLength(100); // At most 100 characters

        RuleFor(x => x.RegexField)
            .Matches(@"^[a-zA-Z0-9]+$"); // Alphanumeric only

        RuleFor(x => x.EmailField)
            .EmailAddress(); // Valid email format

        // Numeric validations
        RuleFor(x => x.GreaterThanField)
            .GreaterThan(0); // > 0

        RuleFor(x => x.GreaterThanOrEqualField)
            .GreaterThanOrEqualTo(0); // >= 0

        RuleFor(x => x.LessThanField)
            .LessThan(100); // < 100

        RuleFor(x => x.LessThanOrEqualField)
            .LessThanOrEqualTo(100); // <= 100

        RuleFor(x => x.RangeField)
            .InclusiveBetween(1, 100); // Between 1 and 100 (inclusive)

        RuleFor(x => x.ExclusiveRangeField)
            .ExclusiveBetween(0, 100); // Between 0 and 100 (exclusive)

        RuleFor(x => x.PrecisionField)
            .PrecisionScale(10, 2, false); // Max 10 digits, 2 decimal places

        // Comparison validations
        RuleFor(x => x.EqualField)
            .Equal("Expected Value");

        RuleFor(x => x.NotEqualField)
            .NotEqual("Forbidden Value");

        RuleFor(x => x.ConfirmPassword)
            .Equal(x => x.Password).WithMessage("Passwords must match");

        // Collection validations
        RuleFor(x => x.CollectionField)
            .NotEmpty(); // Collection must have at least one item

        RuleFor(x => x.MustField)
            .Must(BeValidValue).WithMessage("Custom validation failed");

        // Enum validations
        RuleFor(x => x.EnumField)
            .IsInEnum(); // Must be valid enum value

        // URL validations
        RuleFor(x => x.UrlField)
            .Must(BeValidUrl).WithMessage("Invalid URL");

        // Credit card validation
        RuleFor(x => x.CreditCardField)
            .CreditCard(); // Valid credit card number (Luhn algorithm)

        // Null validation
        RuleFor(x => x.NullableField)
            .Null().When(x => x.SomeCondition);

        // Empty validation
        RuleFor(x => x.EmptyField)
            .Empty().When(x => x.SomeCondition);

        // Scale precision
        RuleFor(x => x.ScalePrecisionField)
            .ScalePrecision(2, 5); // Max 5 digits with 2 decimal places
    }

    private bool BeValidValue(string value)
    {
        // Custom validation logic
        return !string.IsNullOrEmpty(value) && value.Length > 3;
    }

    private bool BeValidUrl(string url)
    {
        return Uri.TryCreate(url, UriKind.Absolute, out var uri)
            && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
    }
}

public class SampleModel
{
    public string RequiredField { get; set; }
    public string NotNullField { get; set; }
    public string LengthField { get; set; }
    public string MinLengthField { get; set; }
    public string MaxLengthField { get; set; }
    public string RegexField { get; set; }
    public string EmailField { get; set; }
    public int GreaterThanField { get; set; }
    public int GreaterThanOrEqualField { get; set; }
    public int LessThanField { get; set; }
    public int LessThanOrEqualField { get; set; }
    public int RangeField { get; set; }
    public int ExclusiveRangeField { get; set; }
    public decimal PrecisionField { get; set; }
    public string EqualField { get; set; }
    public string NotEqualField { get; set; }
    public string Password { get; set; }
    public string ConfirmPassword { get; set; }
    public List<string> CollectionField { get; set; }
    public string MustField { get; set; }
    public Status EnumField { get; set; }
    public string UrlField { get; set; }
    public string CreditCardField { get; set; }
    public string NullableField { get; set; }
    public string EmptyField { get; set; }
    public decimal ScalePrecisionField { get; set; }
    public bool SomeCondition { get; set; }
}

public enum Status
{
    Pending,
    Active,
    Inactive
}

---

Custom Validation Rules

Exercise 4: Create Custom Validators

Question: Implement custom validation rules for complex scenarios.

Answer

// Custom validator using Must
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderRequestValidator()
    {
        RuleFor(x => x.DeliveryDate)
            .Must(BeAFutureDate).WithMessage("Delivery date must be in the future")
            .Must(BeABusinessDay).WithMessage("Delivery must be on a business day");

        RuleFor(x => x.CreditCardNumber)
            .Must(BeValidCreditCard).WithMessage("Invalid credit card number");

        RuleFor(x => x.PhoneNumber)
            .Must(BeValidPhoneNumber).WithMessage("Invalid phone number format");
    }

    private bool BeAFutureDate(DateTime date)
    {
        return date.Date > DateTime.Today;
    }

    private bool BeABusinessDay(DateTime date)
    {
        return date.DayOfWeek != DayOfWeek.Saturday
            && date.DayOfWeek != DayOfWeek.Sunday;
    }

    private bool BeValidCreditCard(string cardNumber)
    {
        // Luhn algorithm implementation
        if (string.IsNullOrWhiteSpace(cardNumber))
            return false;

        cardNumber = cardNumber.Replace(" ", "");

        if (!cardNumber.All(char.IsDigit))
            return false;

        int sum = 0;
        bool alternate = false;

        for (int i = cardNumber.Length - 1; i >= 0; i--)
        {
            int digit = cardNumber[i] - '0';

            if (alternate)
            {
                digit *= 2;
                if (digit > 9)
                    digit -= 9;
            }

            sum += digit;
            alternate = !alternate;
        }

        return sum % 10 == 0;
    }

    private bool BeValidPhoneNumber(string phoneNumber)
    {
        if (string.IsNullOrWhiteSpace(phoneNumber))
            return false;

        // Remove formatting
        var cleaned = new string(phoneNumber.Where(char.IsDigit).ToArray());

        // US phone number: 10 digits
        return cleaned.Length == 10;
    }
}

// Custom validator with context
public class PasswordValidator : AbstractValidator<ChangePasswordRequest>
{
    public PasswordValidator()
    {
        RuleFor(x => x.NewPassword)
            .Must((request, newPassword) => BeStrongPassword(newPassword))
            .WithMessage("Password must contain uppercase, lowercase, digit, and special character");

        RuleFor(x => x.NewPassword)
            .Must((request, newPassword) => BeDifferentFromOldPassword(request.OldPassword, newPassword))
            .WithMessage("New password must be different from old password");
    }

    private bool BeStrongPassword(string password)
    {
        if (string.IsNullOrWhiteSpace(password) || password.Length < 8)
            return false;

        bool hasUpper = password.Any(char.IsUpper);
        bool hasLower = password.Any(char.IsLower);
        bool hasDigit = password.Any(char.IsDigit);
        bool hasSpecial = password.Any(c => !char.IsLetterOrDigit(c));

        return hasUpper && hasLower && hasDigit && hasSpecial;
    }

    private bool BeDifferentFromOldPassword(string oldPassword, string newPassword)
    {
        return oldPassword != newPassword;
    }
}

public class ChangePasswordRequest
{
    public string OldPassword { get; set; }
    public string NewPassword { get; set; }
}

// Reusable custom validators
public static class CustomValidators
{
    public static IRuleBuilderOptions<T, string> MustBeValidUrl<T>(
        this IRuleBuilder<T, string> ruleBuilder)
    {
        return ruleBuilder.Must(url =>
        {
            if (string.IsNullOrWhiteSpace(url))
                return false;

            return Uri.TryCreate(url, UriKind.Absolute, out var uri)
                && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
        }).WithMessage("'{PropertyName}' must be a valid URL");
    }

    public static IRuleBuilderOptions<T, string> MustBeAlphanumeric<T>(
        this IRuleBuilder<T, string> ruleBuilder)
    {
        return ruleBuilder.Matches(@"^[a-zA-Z0-9]+$")
            .WithMessage("'{PropertyName}' must contain only letters and numbers");
    }

    public static IRuleBuilderOptions<T, DateTime> MustBeInThePast<T>(
        this IRuleBuilder<T, DateTime> ruleBuilder)
    {
        return ruleBuilder.Must(date => date < DateTime.Now)
            .WithMessage("'{PropertyName}' must be in the past");
    }

    public static IRuleBuilderOptions<T, DateTime> MustBeInTheFuture<T>(
        this IRuleBuilder<T, DateTime> ruleBuilder)
    {
        return ruleBuilder.Must(date => date > DateTime.Now)
            .WithMessage("'{PropertyName}' must be in the future");
    }
}

// Usage of custom validators
public class EventValidator : AbstractValidator<CreateEventRequest>
{
    public EventValidator()
    {
        RuleFor(x => x.EventDate)
            .MustBeInTheFuture();

        RuleFor(x => x.WebsiteUrl)
            .MustBeValidUrl();

        RuleFor(x => x.EventCode)
            .MustBeAlphanumeric();
    }
}

public class CreateEventRequest
{
    public DateTime EventDate { get; set; }
    public string WebsiteUrl { get; set; }
    public string EventCode { get; set; }
}

public class CreateOrderRequest
{
    public DateTime DeliveryDate { get; set; }
    public string CreditCardNumber { get; set; }
    public string PhoneNumber { get; set; }
}

---

Async Validation

Exercise 5: Implement Async Validators

Question: Create validators that perform asynchronous checks (e.g., database lookups).

Answer

// Application/Validators/CreateUserRequestValidator.cs
public class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>
{
    private readonly IUserRepository _userRepository;
    private readonly IEmailService _emailService;

    public CreateUserRequestValidator(
        IUserRepository userRepository,
        IEmailService emailService)
    {
        _userRepository = userRepository;
        _emailService = emailService;

        RuleFor(x => x.Username)
            .NotEmpty()
            .MustAsync(BeUniqueUsername)
            .WithMessage("Username '{PropertyValue}' is already taken");

        RuleFor(x => x.Email)
            .NotEmpty()
            .EmailAddress()
            .MustAsync(BeUniqueEmail)
            .WithMessage("Email '{PropertyValue}' is already registered")
            .MustAsync(BeValidEmailDomain)
            .WithMessage("Email domain is not allowed");

        RuleFor(x => x.ReferralCode)
            .MustAsync(BeValidReferralCode)
            .When(x => !string.IsNullOrEmpty(x.ReferralCode))
            .WithMessage("Invalid referral code");
    }

    private async Task<bool> BeUniqueUsername(string username, CancellationToken cancellationToken)
    {
        var existingUser = await _userRepository.GetByUsernameAsync(username);
        return existingUser == null;
    }

    private async Task<bool> BeUniqueEmail(string email, CancellationToken cancellationToken)
    {
        var existingUser = await _userRepository.GetByEmailAsync(email);
        return existingUser == null;
    }

    private async Task<bool> BeValidEmailDomain(string email, CancellationToken cancellationToken)
    {
        var domain = email.Split('@')[1];
        var blockedDomains = await _emailService.GetBlockedDomainsAsync();
        return !blockedDomains.Contains(domain);
    }

    private async Task<bool> BeValidReferralCode(string referralCode, CancellationToken cancellationToken)
    {
        if (string.IsNullOrWhiteSpace(referralCode))
            return true;

        var referral = await _userRepository.GetByReferralCodeAsync(referralCode);
        return referral != null && referral.IsActive;
    }
}

// More complex async validation with context
public class TransferFundsRequestValidator : AbstractValidator<TransferFundsRequest>
{
    private readonly IBankAccountRepository _accountRepository;
    private readonly IFraudDetectionService _fraudService;

    public TransferFundsRequestValidator(
        IBankAccountRepository accountRepository,
        IFraudDetectionService fraudService)
    {
        _accountRepository = accountRepository;
        _fraudService = fraudService;

        RuleFor(x => x.FromAccountId)
            .MustAsync(AccountExists)
            .WithMessage("Source account not found");

        RuleFor(x => x.ToAccountId)
            .MustAsync(AccountExists)
            .WithMessage("Destination account not found");

        RuleFor(x => x.Amount)
            .GreaterThan(0)
            .MustAsync(HaveSufficientFunds)
            .WithMessage("Insufficient funds")
            .MustAsync(NotExceedDailyLimit)
            .WithMessage("Amount exceeds daily transfer limit");

        RuleFor(x => x)
            .MustAsync(NotBeFraudulent)
            .WithMessage("Transaction flagged as potentially fraudulent");
    }

    private async Task<bool> AccountExists(Guid accountId, CancellationToken ct)
    {
        var account = await _accountRepository.GetByIdAsync(accountId);
        return account != null;
    }

    private async Task<bool> HaveSufficientFunds(
        TransferFundsRequest request,
        decimal amount,
        CancellationToken ct)
    {
        var account = await _accountRepository.GetByIdAsync(request.FromAccountId);
        return account != null && account.Balance >= amount;
    }

    private async Task<bool> NotExceedDailyLimit(
        TransferFundsRequest request,
        decimal amount,
        CancellationToken ct)
    {
        var todayTransfers = await _accountRepository.GetTodayTransfersAsync(request.FromAccountId);
        var totalToday = todayTransfers.Sum(t => t.Amount);
        return (totalToday + amount) <= 10000m; // $10,000 daily limit
    }

    private async Task<bool> NotBeFraudulent(
        TransferFundsRequest request,
        CancellationToken ct)
    {
        var fraudCheck = await _fraudService.CheckTransactionAsync(
            request.FromAccountId,
            request.ToAccountId,
            request.Amount
        );

        return !fraudCheck.IsFraudulent;
    }
}

public class CreateUserRequest
{
    public string Username { get; set; }
    public string Email { get; set; }
    public string ReferralCode { get; set; }
}

public class TransferFundsRequest
{
    public Guid FromAccountId { get; set; }
    public Guid ToAccountId { get; set; }
    public decimal Amount { get; set; }
}

// Usage
public class UserService
{
    private readonly IValidator<CreateUserRequest> _validator;

    public async Task<Result> CreateUserAsync(CreateUserRequest request)
    {
        // Async validation
        var validationResult = await _validator.ValidateAsync(request);

        if (!validationResult.IsValid)
        {
            return Result.Failure(validationResult.Errors);
        }

        // Process user creation
        return Result.Success();
    }
}

Key Points:

  • Use MustAsync for async validation rules
  • Always accept CancellationToken parameter
  • Async validators support dependency injection
  • Can access entire object or individual properties
  • Be mindful of performance with multiple async calls

---

Dependent Rules (When/Unless)

Exercise 6: Conditional Validation with When/Unless

Question: Implement conditional validation that depends on other property values.

Answer

public class OrderRequestValidator : AbstractValidator<OrderRequest>
{
    public OrderRequestValidator()
    {
        // Basic required fields
        RuleFor(x => x.CustomerName)
            .NotEmpty();

        // Shipping address required when delivery type is "Delivery"
        RuleFor(x => x.ShippingAddress)
            .NotEmpty()
            .When(x => x.DeliveryType == DeliveryType.Delivery)
            .WithMessage("Shipping address is required for delivery orders");

        // Shipping address NOT required when pickup
        RuleFor(x => x.ShippingAddress)
            .Empty()
            .Unless(x => x.DeliveryType == DeliveryType.Delivery)
            .WithMessage("Shipping address should not be provided for pickup orders");

        // Gift message only when IsGift is true
        RuleFor(x => x.GiftMessage)
            .NotEmpty()
            .When(x => x.IsGift)
            .WithMessage("Gift message is required when order is marked as gift");

        RuleFor(x => x.GiftMessage)
            .MaximumLength(200)
            .When(x => x.IsGift);

        // Business rules based on payment method
        RuleFor(x => x.CreditCardNumber)
            .NotEmpty()
            .CreditCard()
            .When(x => x.PaymentMethod == PaymentMethod.CreditCard)
            .WithMessage("Valid credit card number required");

        RuleFor(x => x.CreditCardExpiry)
            .NotEmpty()
            .When(x => x.PaymentMethod == PaymentMethod.CreditCard);

        RuleFor(x => x.CreditCardCVV)
            .NotEmpty()
            .Length(3, 4)
            .When(x => x.PaymentMethod == PaymentMethod.CreditCard);

        // PayPal email required for PayPal payments
        RuleFor(x => x.PayPalEmail)
            .NotEmpty()
            .EmailAddress()
            .When(x => x.PaymentMethod == PaymentMethod.PayPal);

        // Discount code validation
        RuleFor(x => x.DiscountCode)
            .NotEmpty()
            .When(x => x.ApplyDiscount)
            .WithMessage("Discount code required when applying discount");

        // Age verification for restricted products
        RuleFor(x => x.DateOfBirth)
            .NotEmpty()
            .When(x => x.ContainsRestrictedItems)
            .WithMessage("Date of birth required for age verification");

        RuleFor(x => x.DateOfBirth)
            .Must(BeAtLeast18YearsOld)
            .When(x => x.ContainsRestrictedItems)
            .WithMessage("Must be at least 18 years old");

        // Complex When condition
        RuleFor(x => x.CompanyName)
            .NotEmpty()
            .When(x => x.CustomerType == CustomerType.Business && x.OrderTotal > 1000)
            .WithMessage("Company name required for business orders over $1000");

        // Unless condition (opposite of When)
        RuleFor(x => x.Email)
            .NotEmpty()
            .Unless(x => x.IsAnonymous)
            .WithMessage("Email required for registered users");

        // Chaining When conditions
        RuleFor(x => x.TaxId)
            .NotEmpty()
            .When(x => x.CustomerType == CustomerType.Business)
            .When(x => x.Country == "US")
            .WithMessage("Tax ID required for US business customers");
    }

    private bool BeAtLeast18YearsOld(DateTime dateOfBirth)
    {
        var age = DateTime.Today.Year - dateOfBirth.Year;
        if (dateOfBirth.Date > DateTime.Today.AddYears(-age))
            age--;

        return age >= 18;
    }
}

// More complex conditional validation
public class EmployeeValidator : AbstractValidator<Employee>
{
    public EmployeeValidator()
    {
        // Manager-specific validations
        When(x => x.IsManager, () =>
        {
            RuleFor(x => x.Department)
                .NotEmpty()
                .WithMessage("Department is required for managers");

            RuleFor(x => x.ManagementLevel)
                .IsInEnum()
                .WithMessage("Valid management level required");

            RuleFor(x => x.DirectReports)
                .NotEmpty()
                .WithMessage("Managers must have at least one direct report");
        });

        // Non-manager validations
        Unless(x => x.IsManager, () =>
        {
            RuleFor(x => x.ManagerId)
                .NotEmpty()
                .WithMessage("Manager assignment required for non-managers");
        });

        // Part-time employee rules
        When(x => x.EmploymentType == EmploymentType.PartTime, () =>
        {
            RuleFor(x => x.HoursPerWeek)
                .LessThan(40)
                .WithMessage("Part-time employees must work less than 40 hours");
        });

        // Full-time employee rules
        When(x => x.EmploymentType == EmploymentType.FullTime, () =>
        {
            RuleFor(x => x.BenefitsPackage)
                .NotEmpty()
                .WithMessage("Benefits package required for full-time employees");
        });
    }
}

public class OrderRequest
{
    public string CustomerName { get; set; }
    public DeliveryType DeliveryType { get; set; }
    public string ShippingAddress { get; set; }
    public bool IsGift { get; set; }
    public string GiftMessage { get; set; }
    public PaymentMethod PaymentMethod { get; set; }
    public string CreditCardNumber { get; set; }
    public string CreditCardExpiry { get; set; }
    public string CreditCardCVV { get; set; }
    public string PayPalEmail { get; set; }
    public bool ApplyDiscount { get; set; }
    public string DiscountCode { get; set; }
    public bool ContainsRestrictedItems { get; set; }
    public DateTime? DateOfBirth { get; set; }
    public CustomerType CustomerType { get; set; }
    public decimal OrderTotal { get; set; }
    public string CompanyName { get; set; }
    public bool IsAnonymous { get; set; }
    public string Email { get; set; }
    public string Country { get; set; }
    public string TaxId { get; set; }
}

public enum DeliveryType { Delivery, Pickup }
public enum PaymentMethod { CreditCard, PayPal, Cash }
public enum CustomerType { Individual, Business }

public class Employee
{
    public bool IsManager { get; set; }
    public string Department { get; set; }
    public int ManagementLevel { get; set; }
    public List<Guid> DirectReports { get; set; }
    public Guid? ManagerId { get; set; }
    public EmploymentType EmploymentType { get; set; }
    public int HoursPerWeek { get; set; }
    public string BenefitsPackage { get; set; }
}

public enum EmploymentType { FullTime, PartTime, Contract }

Key Concepts:

  • When(predicate, action): Apply rules only when condition is true
  • Unless(predicate, action): Apply rules only when condition is false
  • Can chain multiple When conditions
  • Can group multiple rules inside When block
  • Useful for complex business rules

---

Nested Validators

Exercise 7: Validate Complex Nested Objects

Question: Create validators for objects that contain nested complex objects.

Answer

// Domain models
public class CreateOrderRequest
{
    public Guid CustomerId { get; set; }
    public Address ShippingAddress { get; set; }
    public Address BillingAddress { get; set; }
    public List<OrderItem> Items { get; set; }
    public PaymentInfo Payment { get; set; }
}

public class Address
{
    public string Street { get; set; }
    public string City { get; set; }
    public string State { get; set; }
    public string ZipCode { get; set; }
    public string Country { get; set; }
}

public class OrderItem
{
    public Guid ProductId { get; set; }
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
}

public class PaymentInfo
{
    public PaymentMethod Method { get; set; }
    public CreditCardInfo CreditCard { get; set; }
}

public class CreditCardInfo
{
    public string CardNumber { get; set; }
    public string CardholderName { get; set; }
    public string ExpiryDate { get; set; }
    public string CVV { get; set; }
}

// Validators for nested objects
public class AddressValidator : AbstractValidator<Address>
{
    public AddressValidator()
    {
        RuleFor(x => x.Street)
            .NotEmpty()
            .MaximumLength(200);

        RuleFor(x => x.City)
            .NotEmpty()
            .MaximumLength(100);

        RuleFor(x => x.State)
            .NotEmpty()
            .Length(2)
            .WithMessage("State must be 2-letter code");

        RuleFor(x => x.ZipCode)
            .NotEmpty()
            .Matches(@"^\d{5}(-\d{4})?$")
            .WithMessage("Invalid ZIP code format");

        RuleFor(x => x.Country)
            .NotEmpty()
            .Must(BeValidCountryCode)
            .WithMessage("Invalid country code");
    }

    private bool BeValidCountryCode(string country)
    {
        var validCodes = new[] { "US", "CA", "UK", "AU" };
        return validCodes.Contains(country);
    }
}

public class OrderItemValidator : AbstractValidator<OrderItem>
{
    public OrderItemValidator()
    {
        RuleFor(x => x.ProductId)
            .NotEmpty();

        RuleFor(x => x.Quantity)
            .GreaterThan(0)
            .LessThanOrEqualTo(100)
            .WithMessage("Quantity must be between 1 and 100");

        RuleFor(x => x.UnitPrice)
            .GreaterThan(0)
            .WithMessage("Unit price must be positive");
    }
}

public class CreditCardInfoValidator : AbstractValidator<CreditCardInfo>
{
    public CreditCardInfoValidator()
    {
        RuleFor(x => x.CardNumber)
            .NotEmpty()
            .CreditCard()
            .WithMessage("Invalid credit card number");

        RuleFor(x => x.CardholderName)
            .NotEmpty()
            .MaximumLength(100);

        RuleFor(x => x.ExpiryDate)
            .NotEmpty()
            .Matches(@"^(0[1-9]|1[0-2])\/\d{2}$")
            .WithMessage("Expiry date must be in MM/YY format")
            .Must(BeInTheFuture)
            .WithMessage("Card has expired");

        RuleFor(x => x.CVV)
            .NotEmpty()
            .Matches(@"^\d{3,4}$")
            .WithMessage("CVV must be 3 or 4 digits");
    }

    private bool BeInTheFuture(string expiryDate)
    {
        if (string.IsNullOrEmpty(expiryDate) || !expiryDate.Contains("/"))
            return false;

        var parts = expiryDate.Split('/');
        if (parts.Length != 2)
            return false;

        if (!int.TryParse(parts[0], out var month) || !int.TryParse(parts[1], out var year))
            return false;

        year += 2000; // Convert YY to YYYY
        var expiry = new DateTime(year, month, 1).AddMonths(1).AddDays(-1);

        return expiry >= DateTime.Today;
    }
}

public class PaymentInfoValidator : AbstractValidator<PaymentInfo>
{
    public PaymentInfoValidator()
    {
        RuleFor(x => x.Method)
            .IsInEnum();

        // Nested validator for credit card
        RuleFor(x => x.CreditCard)
            .SetValidator(new CreditCardInfoValidator())
            .When(x => x.Method == PaymentMethod.CreditCard);
    }
}

// Main validator with nested validators
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderRequestValidator()
    {
        RuleFor(x => x.CustomerId)
            .NotEmpty();

        // Use SetValidator for nested objects
        RuleFor(x => x.ShippingAddress)
            .NotNull()
            .SetValidator(new AddressValidator());

        RuleFor(x => x.BillingAddress)
            .NotNull()
            .SetValidator(new AddressValidator());

        // Validate collection of nested objects
        RuleFor(x => x.Items)
            .NotEmpty()
            .WithMessage("Order must contain at least one item");

        RuleForEach(x => x.Items)
            .SetValidator(new OrderItemValidator());

        // Nested payment validator
        RuleFor(x => x.Payment)
            .NotNull()
            .SetValidator(new PaymentInfoValidator());
    }
}

// Alternative: Inline nested validation
public class InlineNestedValidator : AbstractValidator<CreateOrderRequest>
{
    public InlineNestedValidator()
    {
        RuleFor(x => x.ShippingAddress)
            .NotNull()
            .ChildRules(address =>
            {
                address.RuleFor(a => a.Street).NotEmpty();
                address.RuleFor(a => a.City).NotEmpty();
                address.RuleFor(a => a.State).Length(2);
                address.RuleFor(a => a.ZipCode).Matches(@"^\d{5}$");
            });
    }
}

// Usage
public class OrderService
{
    private readonly IValidator<CreateOrderRequest> _validator;

    public async Task<Result> CreateOrderAsync(CreateOrderRequest request)
    {
        var validationResult = await _validator.ValidateAsync(request);

        if (!validationResult.IsValid)
        {
            // Errors include nested property paths
            foreach (var error in validationResult.Errors)
            {
                Console.WriteLine($"{error.PropertyName}: {error.ErrorMessage}");
                // Examples:
                // ShippingAddress.Street: 'Street' must not be empty
                // Items[0].Quantity: Quantity must be between 1 and 100
                // Payment.CreditCard.CardNumber: Invalid credit card number
            }

            return Result.Failure(validationResult.Errors);
        }

        return Result.Success();
    }
}

Key Points:

  • Use SetValidator() for nested object validation
  • Use RuleForEach() with SetValidator() for collections
  • Use ChildRules() for inline nested validation
  • Property paths include nested hierarchy (e.g., Address.Street)
  • Can reuse validators across different parent validators

---

Collection Validation

Exercise 8: Validate Collections

Question: Demonstrate various techniques for validating collections.

Answer

public class PlaylistValidator : AbstractValidator<Playlist>
{
    public PlaylistValidator()
    {
        // Collection must not be empty
        RuleFor(x => x.Songs)
            .NotEmpty()
            .WithMessage("Playlist must contain at least one song");

        // Collection size constraints
        RuleFor(x => x.Songs)
            .Must(songs => songs.Count <= 100)
            .WithMessage("Playlist cannot contain more than 100 songs");

        // Validate each item in collection
        RuleForEach(x => x.Songs)
            .SetValidator(new SongValidator());

        // Validate each item with index
        RuleForEach(x => x.Songs)
            .ChildRules(song =>
            {
                song.RuleFor(s => s.Title).NotEmpty();
                song.RuleFor(s => s.Duration).GreaterThan(0);
            });

        // Complex collection validation
        RuleFor(x => x.Songs)
            .Must(HaveUniqueIds)
            .WithMessage("Playlist cannot contain duplicate songs");

        RuleFor(x => x.Songs)
            .Must(NotExceedTotalDuration)
            .WithMessage("Total playlist duration cannot exceed 10 hours");

        // Validate collection based on another property
        RuleFor(x => x.Songs)
            .Must((playlist, songs) => songs.Count >= playlist.MinSongs)
            .WithMessage("Playlist must contain at least {MinSongs} songs");
    }

    private bool HaveUniqueIds(List<Song> songs)
    {
        var uniqueIds = songs.Select(s => s.Id).Distinct().Count();
        return uniqueIds == songs.Count;
    }

    private bool NotExceedTotalDuration(List<Song> songs)
    {
        var totalDuration = songs.Sum(s => s.Duration);
        return totalDuration <= TimeSpan.FromHours(10).TotalSeconds;
    }
}

public class SongValidator : AbstractValidator<Song>
{
    public SongValidator()
    {
        RuleFor(x => x.Title)
            .NotEmpty()
            .MaximumLength(200);

        RuleFor(x => x.Artist)
            .NotEmpty()
            .MaximumLength(100);

        RuleFor(x => x.Duration)
            .GreaterThan(0)
            .LessThan(3600); // Max 1 hour
    }
}

// More complex collection scenarios
public class ShoppingCartValidator : AbstractValidator<ShoppingCart>
{
    private readonly IProductRepository _productRepository;

    public ShoppingCartValidator(IProductRepository productRepository)
    {
        _productRepository = productRepository;

        // Basic collection validation
        RuleFor(x => x.Items)
            .NotEmpty()
            .WithMessage("Shopping cart cannot be empty");

        // Validate each item
        RuleForEach(x => x.Items)
            .SetValidator(new CartItemValidator());

        // Async collection validation
        RuleFor(x => x.Items)
            .MustAsync(AllProductsExist)
            .WithMessage("One or more products no longer exist");

        RuleFor(x => x.Items)
            .MustAsync(AllProductsInStock)
            .WithMessage("One or more products are out of stock");

        // Cross-item validation
        RuleFor(x => x.Items)
            .Must(NotContainIncompatibleProducts)
            .WithMessage("Cart contains incompatible products");

        // Total validation
        RuleFor(x => x.Items)
            .Must(items => items.Sum(i => i.Quantity * i.UnitPrice) <= 10000)
            .WithMessage("Cart total cannot exceed $10,000");
    }

    private async Task<bool> AllProductsExist(List<CartItem> items, CancellationToken ct)
    {
        var productIds = items.Select(i => i.ProductId).ToList();
        var products = await _productRepository.GetByIdsAsync(productIds);
        return products.Count == productIds.Count;
    }

    private async Task<bool> AllProductsInStock(List<CartItem> items, CancellationToken ct)
    {
        foreach (var item in items)
        {
            var product = await _productRepository.GetByIdAsync(item.ProductId);
            if (product == null || product.StockQuantity < item.Quantity)
                return false;
        }
        return true;
    }

    private bool NotContainIncompatibleProducts(List<CartItem> items)
    {
        // Business rule: Can't mix digital and physical products
        var hasDigital = items.Any(i => i.IsDigital);
        var hasPhysical = items.Any(i => !i.IsDigital);
        return !(hasDigital && hasPhysical);
    }
}

public class CartItemValidator : AbstractValidator<CartItem>
{
    public CartItemValidator()
    {
        RuleFor(x => x.ProductId)
            .NotEmpty();

        RuleFor(x => x.Quantity)
            .GreaterThan(0)
            .LessThanOrEqualTo(100);

        RuleFor(x => x.UnitPrice)
            .GreaterThan(0);
    }
}

// Collection with conditional validation
public class BatchOrderValidator : AbstractValidator<BatchOrder>
{
    public BatchOrderValidator()
    {
        RuleFor(x => x.Orders)
            .NotEmpty()
            .WithMessage("Batch must contain at least one order");

        RuleFor(x => x.Orders)
            .Must(orders => orders.Count <= 1000)
            .WithMessage("Batch cannot contain more than 1000 orders");

        // Different validation for different order types
        RuleForEach(x => x.Orders)
            .SetValidator(order => new OrderValidator())
            .When(order => order.OrderType == OrderType.Standard);

        RuleForEach(x => x.Orders)
            .SetValidator(order => new ExpressOrderValidator())
            .When(order => order.OrderType == OrderType.Express);
    }
}

public class Playlist
{
    public string Name { get; set; }
    public List<Song> Songs { get; set; }
    public int MinSongs { get; set; }
}

public class Song
{
    public Guid Id { get; set; }
    public string Title { get; set; }
    public string Artist { get; set; }
    public int Duration { get; set; }
}

public class ShoppingCart
{
    public Guid Id { get; set; }
    public List<CartItem> Items { get; set; }
}

public class CartItem
{
    public Guid ProductId { get; set; }
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
    public bool IsDigital { get; set; }
}

public class BatchOrder
{
    public List<Order> Orders { get; set; }
}

public class Order
{
    public OrderType OrderType { get; set; }
}

public enum OrderType { Standard, Express }

---

RuleSet Usage

Exercise 9: Use RuleSets for Different Scenarios

Question: Implement RuleSets to apply different validation rules for create vs update scenarios.

Answer

public class ProductValidator : AbstractValidator<Product>
{
    public ProductValidator()
    {
        // Default rules (always applied)
        RuleFor(x => x.Name)
            .NotEmpty()
            .MaximumLength(200);

        RuleFor(x => x.Price)
            .GreaterThan(0);

        // Create-specific rules
        RuleSet("Create", () =>
        {
            RuleFor(x => x.Id)
                .Empty()
                .WithMessage("ID must not be provided for new products");

            RuleFor(x => x.SKU)
                .NotEmpty()
                .WithMessage("SKU is required when creating a product");

            RuleFor(x => x.Category)
                .NotEmpty()
                .WithMessage("Category is required for new products");
        });

        // Update-specific rules
        RuleSet("Update", () =>
        {
            RuleFor(x => x.Id)
                .NotEmpty()
                .WithMessage("ID is required for updates");

            RuleFor(x => x.SKU)
                .Empty()
                .WithMessage("SKU cannot be changed");
        });

        // Delete-specific rules
        RuleSet("Delete", () =>
        {
            RuleFor(x => x.Id)
                .NotEmpty();

            RuleFor(x => x.HasActiveOrders)
                .Equal(false)
                .WithMessage("Cannot delete product with active orders");
        });

        // Publish-specific rules
        RuleSet("Publish", () =>
        {
            RuleFor(x => x.Description)
                .NotEmpty()
                .MinimumLength(100)
                .WithMessage("Description must be at least 100 characters for published products");

            RuleFor(x => x.Images)
                .NotEmpty()
                .WithMessage("At least one image required for published products");

            RuleFor(x => x.StockQuantity)
                .GreaterThan(0)
                .WithMessage("Cannot publish product with zero stock");
        });

        // Multiple RuleSets
        RuleSet("Create, Update", () =>
        {
            // Rules for both create and update
            RuleFor(x => x.Price)
                .LessThan(100000)
                .WithMessage("Price cannot exceed $100,000");
        });
    }
}

// Usage in service
public class ProductService
{
    private readonly IValidator<Product> _validator;

    // Create
    public async Task<Result> CreateProductAsync(Product product)
    {
        var result = await _validator.ValidateAsync(product, options =>
        {
            options.IncludeRuleSets("Create");
            options.IncludeDefaultRules(); // Include default rules too
        });

        if (!result.IsValid)
            return Result.Failure(result.Errors);

        // Save product
        return Result.Success();
    }

    // Update
    public async Task<Result> UpdateProductAsync(Product product)
    {
        var result = await _validator.ValidateAsync(product, options =>
        {
            options.IncludeRuleSets("Update");
        });

        if (!result.IsValid)
            return Result.Failure(result.Errors);

        // Update product
        return Result.Success();
    }

    // Delete
    public async Task<Result> DeleteProductAsync(Product product)
    {
        var result = await _validator.ValidateAsync(product, options =>
        {
            options.IncludeRuleSets("Delete");
        });

        if (!result.IsValid)
            return Result.Failure(result.Errors);

        // Delete product
        return Result.Success();
    }

    // Publish
    public async Task<Result> PublishProductAsync(Product product)
    {
        var result = await _validator.ValidateAsync(product, options =>
        {
            options.IncludeRuleSets("Publish");
            options.IncludeDefaultRules();
        });

        if (!result.IsValid)
            return Result.Failure(result.Errors);

        product.IsPublished = true;
        return Result.Success();
    }
}

// More complex RuleSet example
public class UserValidator : AbstractValidator<User>
{
    public UserValidator()
    {
        // Always required
        RuleFor(x => x.Email)
            .NotEmpty()
            .EmailAddress();

        // Registration rules
        RuleSet("Register", () =>
        {
            RuleFor(x => x.Password)
                .NotEmpty()
                .MinimumLength(8)
                .Matches(@"[A-Z]").WithMessage("Password must contain uppercase")
                .Matches(@"[a-z]").WithMessage("Password must contain lowercase")
                .Matches(@"[0-9]").WithMessage("Password must contain digit");

            RuleFor(x => x.ConfirmPassword)
                .Equal(x => x.Password);

            RuleFor(x => x.AcceptedTerms)
                .Equal(true)
                .WithMessage("Must accept terms and conditions");
        });

        // Profile update rules
        RuleSet("ProfileUpdate", () =>
        {
            RuleFor(x => x.FirstName)
                .NotEmpty()
                .MaximumLength(50);

            RuleFor(x => x.LastName)
                .NotEmpty()
                .MaximumLength(50);

            RuleFor(x => x.Phone)
                .Matches(@"^\d{10}$")
                .When(x => !string.IsNullOrEmpty(x.Phone));
        });

        // Change password rules
        RuleSet("ChangePassword", () =>
        {
            RuleFor(x => x.CurrentPassword)
                .NotEmpty();

            RuleFor(x => x.NewPassword)
                .NotEmpty()
                .MinimumLength(8)
                .NotEqual(x => x.CurrentPassword)
                .WithMessage("New password must be different from current password");
        });

        // Admin-only rules
        RuleSet("AdminUpdate", () =>
        {
            RuleFor(x => x.Role)
                .NotEmpty()
                .IsInEnum();

            RuleFor(x => x.IsActive)
                .NotNull();
        });
    }
}

public class Product
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public string SKU { get; set; }
    public string Category { get; set; }
    public string Description { get; set; }
    public List<string> Images { get; set; }
    public int StockQuantity { get; set; }
    public bool HasActiveOrders { get; set; }
    public bool IsPublished { get; set; }
}

public class User
{
    public string Email { get; set; }
    public string Password { get; set; }
    public string ConfirmPassword { get; set; }
    public string CurrentPassword { get; set; }
    public string NewPassword { get; set; }
    public bool AcceptedTerms { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Phone { get; set; }
    public string Role { get; set; }
    public bool IsActive { get; set; }
}

RuleSet Benefits:

  • Separate validation logic for different scenarios
  • Avoid creating multiple validators for same entity
  • Can combine multiple RuleSets
  • Can include or exclude default rules
  • Cleaner code organization

---

Integration with ASP.NET Core

Exercise 10: Integrate FluentValidation with ASP.NET Core

Question: Set up automatic validation in ASP.NET Core controllers.

Answer

// Program.cs
var builder = WebApplication.CreateBuilder(args);

// Add controllers
builder.Services.AddControllers();

// Add FluentValidation
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddFluentValidationClientsideAdapters();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();

// Optional: Configure validation behavior
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
    // Customize validation error response
    options.InvalidModelStateResponseFactory = context =>
    {
        var errors = context.ModelState
            .Where(e => e.Value.Errors.Count > 0)
            .Select(e => new
            {
                Field = e.Key,
                Errors = e.Value.Errors.Select(x => x.ErrorMessage).ToArray()
            });

        return new BadRequestObjectResult(new
        {
            Message = "Validation failed",
            Errors = errors
        });
    };
});

var app = builder.Build();

// Controllers with automatic validation
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;

    public ProductsController(IProductService productService)
    {
        _productService = productService;
    }

    [HttpPost]
    public async Task<IActionResult> CreateProduct([FromBody] CreateProductRequest request)
    {
        // Validation happens automatically before this code runs
        // If validation fails, BadRequest is returned automatically

        var result = await _productService.CreateProductAsync(request);
        return Ok(result);
    }

    [HttpPut("{id}")]
    public async Task<IActionResult> UpdateProduct(
        Guid id,
        [FromBody] UpdateProductRequest request)
    {
        // Automatic validation
        var result = await _productService.UpdateProductAsync(id, request);
        return Ok(result);
    }
}

// Manual validation in controller
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IValidator<CreateOrderRequest> _validator;
    private readonly IOrderService _orderService;

    public OrdersController(
        IValidator<CreateOrderRequest> validator,
        IOrderService orderService)
    {
        _validator = validator;
        _orderService = orderService;
    }

    [HttpPost]
    public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
    {
        // Manual validation
        var validationResult = await _validator.ValidateAsync(request);

        if (!validationResult.IsValid)
        {
            // Add errors to ModelState
            foreach (var error in validationResult.Errors)
            {
                ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
            }

            return BadRequest(ModelState);
        }

        var result = await _orderService.CreateOrderAsync(request);
        return Ok(result);
    }

    [HttpPost("batch")]
    public async Task<IActionResult> CreateBatchOrder([FromBody] CreateOrderRequest request)
    {
        // Manual validation with RuleSet
        var validationResult = await _validator.ValidateAsync(request, options =>
        {
            options.IncludeRuleSets("Batch");
        });

        if (!validationResult.IsValid)
        {
            return BadRequest(validationResult.ToDictionary());
        }

        var result = await _orderService.CreateBatchOrderAsync(request);
        return Ok(result);
    }
}

// Custom validation filter
public class ValidateModelAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        if (!context.ModelState.IsValid)
        {
            var errors = context.ModelState
                .Where(e => e.Value.Errors.Count > 0)
                .ToDictionary(
                    e => e.Key,
                    e => e.Value.Errors.Select(x => x.ErrorMessage).ToArray()
                );

            context.Result = new BadRequestObjectResult(new
            {
                Message = "Validation failed",
                Errors = errors
            });
        }
    }
}

// Use custom filter
[ApiController]
[Route("api/[controller]")]
[ValidateModel] // Apply to all actions
public class CustomersController : ControllerBase
{
    [HttpPost]
    public IActionResult CreateCustomer([FromBody] CreateCustomerRequest request)
    {
        // Validation handled by filter
        return Ok();
    }
}

// Validators
public class CreateProductRequestValidator : AbstractValidator<CreateProductRequest>
{
    public CreateProductRequestValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty()
            .MaximumLength(200);

        RuleFor(x => x.Price)
            .GreaterThan(0)
            .WithMessage("Price must be greater than zero");

        RuleFor(x => x.Category)
            .NotEmpty();
    }
}

public class UpdateProductRequestValidator : AbstractValidator<UpdateProductRequest>
{
    public UpdateProductRequestValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty()
            .When(x => !string.IsNullOrEmpty(x.Name));

        RuleFor(x => x.Price)
            .GreaterThan(0)
            .When(x => x.Price.HasValue);
    }
}

// DTOs
public class CreateProductRequest
{
    public string Name { get; set; }
    public decimal Price { get; set; }
    public string Category { get; set; }
    public string Description { get; set; }
}

public class UpdateProductRequest
{
    public string Name { get; set; }
    public decimal? Price { get; set; }
    public string Description { get; set; }
}

public class CreateOrderRequest
{
    public Guid CustomerId { get; set; }
    public List<OrderItem> Items { get; set; }
}

public class CreateCustomerRequest
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }
}

Integration Features:

  1. Automatic validation before controller action
  2. Errors automatically added to ModelState
  3. Can customize error response format
  4. Can use RuleSets for different endpoints
  5. Can create custom validation filters
  6. Client-side validation support

---

Advanced Rule Composition

Exercise 11: CascadeMode Stop

Question: Stop validation on the first failure for a property.

Answer

RuleFor(x => x.Email)
    .Cascade(CascadeMode.Stop)
    .NotEmpty()
    .EmailAddress();

---

Exercise 12: Custom Must Rule

Question: Validate that an order total matches line items.

Answer

RuleFor(x => x)
    .Must(order => order.Lines.Sum(l => l.Total) == order.Total)
    .WithMessage("Order total does not match line items");

---

Exercise 13: Cross-Property Validation

Question: Require that StartDate is before EndDate.

Answer

RuleFor(x => x.StartDate)
    .LessThan(x => x.EndDate)
    .WithMessage("StartDate must be before EndDate");

---

Exercise 14: RuleForEach with Child Validator

Question: Validate each order item with a nested validator.

Answer

RuleForEach(x => x.Items).SetValidator(new OrderItemValidator());

---

Exercise 15: Custom Property Validator

Question: Create a reusable validator for ISO currency codes.

Answer

RuleFor(x => x.Currency)
    .Must(code => IsoCurrencies.All.Contains(code))
    .WithMessage("Invalid currency code");

---

Exercise 16: Async Uniqueness Check

Question: Validate that a username is unique in the database.

Answer

RuleFor(x => x.Username)
    .MustAsync(async (name, ct) => !await _repo.ExistsAsync(name, ct))
    .WithMessage("Username already exists");

---

Exercise 17: DependentRules

Question: Validate card details only when payment method is card.

Answer

RuleFor(x => x.PaymentMethod)
    .Equal("Card")
    .DependentRules(() =>
    {
        RuleFor(x => x.CardNumber).CreditCard();
        RuleFor(x => x.Cvv).Length(3, 4);
    });

---

Exercise 18: When/Unless Conditions

Question: Validate LimitPrice only for limit orders.

Answer

When(x => x.Type == OrderType.Limit, () =>
{
    RuleFor(x => x.LimitPrice).GreaterThan(0);
});

---

Exercise 19: RuleSets for Create vs Update

Question: Use different rules for create and update.

Answer

RuleSet("Create", () =>
{
    RuleFor(x => x.Name).NotEmpty();
});

RuleSet("Update", () =>
{
    RuleFor(x => x.Id).NotEmpty();
});

---

Exercise 20: Include Other Validators

Question: Reuse shared rules across validators.

Answer

Include(new AddressValidator());

---

Exercise 21: ValidationContext Root Data

Question: Pass contextual data into the validator.

Answer

RuleFor(x => x.Symbol)
    .Must((root, symbol, context) =>
    {
        var allowed = (HashSet<string>)context.RootContextData["allowed"];
        return allowed.Contains(symbol);
    });

---

Exercise 22: Severity and Error Codes

Question: Tag validation errors with codes and severity.

Answer

RuleFor(x => x.Price)
    .GreaterThan(0)
    .WithErrorCode("PRICE_001")
    .WithSeverity(Severity.Error);

---

Exercise 23: Localized Messages

Question: Localize messages using resources.

Answer

RuleFor(x => x.Email)
    .NotEmpty()
    .WithMessage(Resources.Messages.EmailRequired);

---

Exercise 24: Polymorphic Validation

Question: Validate different DTO subtypes with SetInheritanceValidator.

Answer

RuleFor(x => x.Order)
    .SetInheritanceValidator(v =>
    {
        v.Add<LimitOrderDto>(new LimitOrderValidator());
        v.Add<MarketOrderDto>(new MarketOrderValidator());
    });

---

Exercise 25: Validate Partial Updates

Question: Validate only provided properties in a PATCH request.

Answer

public void ValidatePatch(UpdateUserDto dto)
{
    _validator.Validate(dto, options => options.IncludeProperties("Email", "Phone"));
}

---

Exercise 26: Unique Items in a Collection

Question: Ensure a collection has unique symbols.

Answer

RuleFor(x => x.Symbols)
    .Must(list => list.Distinct().Count() == list.Count)
    .WithMessage("Symbols must be unique");

---

Exercise 27: Transform for Normalization

Question: Normalize input before validation.

Answer

RuleFor(x => x.Email)
    .Transform(email => email?.Trim().ToLowerInvariant())
    .EmailAddress();

---

Exercise 28: Custom Validation Block

Question: Use Custom for multi-property checks.

Answer

RuleFor(x => x).Custom((model, context) =>
{
    if (model.Min > model.Max)
        context.AddFailure("Min", "Min must be <= Max");
});

---

Exercise 29: MediatR Pipeline Integration

Question: Validate commands with a pipeline behavior.

Answer

Register ValidationBehavior and throw a domain-specific exception on failure.

---

Validator Testing & Diagnostics

Exercise 30: Test Helper Usage

Question: Write a test that asserts a validation error.

Answer

var validator = new RegisterUserRequestValidator();
var result = validator.TestValidate(new RegisterUserRequest { Email = "bad" });
result.ShouldHaveValidationErrorFor(x => x.Email);